Day 27 最後留了一個很常見的畫面:
setTimeout(() => {
console.log('hello')
}, 1000)
以前我會把它理解成:
JavaScript 看到 setTimeout
↓
停下來等 1 秒
↓
再執行 callback
但如果真的是這樣,網頁只要設一個計時器,整段 JavaScript 不就卡住了?
更奇怪的是:
console.log('A')
setTimeout(() => {
console.log('B')
}, 0)
console.log('C')
第一次看到時,我很容易猜:
A
B
C
畢竟都寫 0 毫秒了。
但實際結果通常是:
A
C
B
所以今天先不背 Event Loop 的定義,只追一個問題:
setTimeout(fn, 0)的 0 到底代表什麼?為什麼 callback 還是不能插隊?
function greet() {
console.log('hello')
}
console.log('A')
greet()
console.log('C')
可以先把 JavaScript 執行想成有一個 Call Stack:
執行全域程式
↓
console.log('A')
↓
greet()
↓
console.log('hello')
↓
greet() 結束
↓
console.log('C')
今天不用先背 Stack 的每一個細節,只抓住一件事:
目前正在執行的同步工作沒結束前,下一段 JavaScript 不會憑空插進來。
再放回:
console.log('A')
setTimeout(() => {
console.log('B')
}, 1000)
console.log('C')
setTimeout 很容易讓我誤會成「JavaScript 自己停著數 1000 ms」。
但在瀏覽器環境裡,計時工作不是讓 Call Stack 卡在原地。
先粗略想成:
JavaScript 執行 setTimeout(...)
↓
把計時工作交給瀏覽器 Runtime
↓
JavaScript 繼續往下跑
↓
console.log('C')
所以 JavaScript 不需要在原地等那一秒。
這就是非同步第一次真正有感的地方:
等待可以交給執行環境處理,但 JavaScript 主執行緒仍然一次執行眼前的一段程式。
也不是。
假設 Timer 到期,執行環境只能先知道:
這個 callback 現在可以準備回來了。
可以先把它想成:
Timer 到期
↓
callback 可以執行
↓
Task Queue
↓
等 Call Stack 有空
Event Loop 會持續關心:
Call Stack 現在空了嗎?
有空時,排隊中的工作才有機會進來。
所以「1000 ms 到了」不是:
第 1000 ms 的那一瞬間一定執行。
而比較接近:
至少要等到這個時間之後,callback 才有資格排隊;真正執行還要等主執行緒空下來。
回到:
console.log('A')
setTimeout(() => {
console.log('B')
}, 0)
console.log('C')
流程可以拆成:
1. 印出 A
2. setTimeout 把 Timer 交給 Runtime
3. JavaScript 繼續往下
4. 印出 C
5. 目前這輪同步工作結束
6. callback 已經在等待
7. Event Loop 讓它回來執行
8. 印出 B
所以結果是:
A
C
B
那個 0 不是:
現在立刻執行。
而是:
沒有要求額外等待一個明顯的延遲時間,但 callback 仍然不能插進尚未結束的同步程式。
實際環境還可能有最小延遲與排程因素,因此「0」也不是精準保證零毫秒後開始執行。
console.log('start')
setTimeout(() => {
console.log('timer')
}, 100)
const start = Date.now()
while (Date.now() - start < 2000) {
// 故意卡住主執行緒約 2 秒
}
console.log('end')
如果把 100 ms 理解成:
100 ms 後一定執行。
就會很困惑。
Timer 雖然早就到期,但 JavaScript 還在跑 while。
所以 callback 只能等。
結果會比較接近:
start
(主執行緒忙約 2 秒)
end
timer
這時我才真正分清楚:
Timer 到期
≠
callback 此刻一定開始執行
以前背:
Event Loop 監控 Call Stack 和 Queue。
很容易隔天忘掉。
現在把它放回剛才的流程:
Call Stack
正在跑同步 JavaScript
↓
Runtime
處理 Timer 等等待工作
↓
Task Queue
放已經可以回來的 callback
↓
Event Loop
確認 Stack 是否空了
↓
有空時安排下一個 task
所以 Event Loop 不是讓 JavaScript「同時跑很多段」。
比較像:
協調目前的同步執行,和那些已經準備好、正在排隊的非同步工作。
至少在今天這個一般瀏覽器主執行緒的模型裡,可以先這樣理解:
JavaScript 主執行緒
一次執行一段 JavaScript
但瀏覽器 Runtime 還能幫忙處理 Timer、網路等待、事件等待等事情。
所以畫面不是:
JavaScript 同時執行 A、B、C
而比較像:
JavaScript 執行 A
↓
把等待工作交出去
↓
繼續執行 C
↓
未來條件完成
↓
callback 排隊
↓
主執行緒有空
↓
再執行 B
直接在瀏覽器 Console 跑:
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
console.log('3')
先猜,再執行。
接著再跑:
console.log('start')
setTimeout(() => {
console.log('timer')
}, 0)
for (let i = 0; i < 1_000_000_000; i++) {
// 故意做很多同步工作
}
console.log('end')
不用在意迴圈到底花幾秒,只觀察:
Timer callback 能不能穿過還沒結束的同步程式?
不會。
這個實驗比只背 Event Loop 的一句定義更有感。
console.log('A')
setTimeout(() => {
console.log('B')
}, 0)
function test() {
console.log('C')
}
test()
console.log('D')
順序是什麼?
A
C
D
B
test() 是 function call,但它仍然是目前這輪同步程式的一部分。
Timer callback 要等同步工作結束,才有機會回到 Call Stack。
setTimeout(fn, 0)的 0 不是「立刻執行」;callback 仍然要等目前同步程式跑完,再由事件迴圈安排回來。
所以看到非同步程式,我現在會先問:
現在誰在 Stack 上?
↓
哪些等待工作交給 Runtime?
↓
完成後排到哪裡?
↓
什麼時候才有機會回到 Stack?